#!/usr/bin/env python
# -*- coding: utf-8 -*-

'''A Remember The Milk API interface.

See `the Remember The Milk API homepage`_ for more info.

This code is STRONGLY based in `FlickrAPI`_. Thanks to those guys for a
solid, mature and well-manteined FlickrAPI module. RtmAPI was cooked in
a weekend, in part because the Remember The Milk developers virtually
copied the Flickr API, and in part because FlickrAPI is a rock solid
module to base your code on.

.. _`the Remember The Milk API homepage`: http://www.rememberthemilk.com/services/api/

.. _`FlickrAPI`: http://flickrapi.sf.net/
'''

__version__ = '0.5'
__all__ = ('RtmAPI', 'IllegalArgumentException', 'RtmError',
           'XMLNode', 'set_log_level', '__version__')
__author__ = u'Mariano Draghi'

# Copyright (c) 2008 by Mariano Draghi
# This is code is STRONGLY based on flickrapi. See http://flickrapi.sf.net/
# 
# This code is subject to the GNU Lesser General Public License (LGPL),
# as can be read on http://www.gnu.org/licenses/lgpl.html

import sys
import md5
import urllib
import urllib2
import os.path
import logging
import copy
import webbrowser

from rtmapi.tokencache import TokenCache, SimpleTokenCache
from rtmapi.xmlnode import XMLNode
from rtmapi.exceptions import IllegalArgumentException, RtmError
from rtmapi.cache import SimpleCache
from rtmapi.callsink import CallSink

logging.basicConfig()
LOG = logging.getLogger(__name__)
LOG.setLevel(logging.INFO)

def make_utf8(dictionary):
    '''Encodes all Unicode strings in the dictionary to UTF-8. Converts
    all other objects to regular strings.
    
    Returns a copy of the dictionary, doesn't touch the original.
    '''
    
    result = {}

    for (key, value) in dictionary.iteritems():
        if isinstance(value, unicode):
            value = value.encode('utf-8')
        else:
            value = str(value)
        result[key] = value
    
    return result
        
def debug(method):
    '''Method decorator for debugging method calls.

    Using this automatically sets the log level to DEBUG.
    '''

    LOG.setLevel(logging.DEBUG)

    def debugged(*args, **kwargs):
        LOG.debug("Call: %s(%s, %s)" % (method.__name__, args,
            kwargs))
        result = method(*args, **kwargs)
        LOG.debug("\tResult: %s" % result)
        return result

    return debugged

# REST parsers, {format: parser_method, ...}. Fill by using the
# @rest_parser(format) function decorator
rest_parsers = {}

def rest_parser(format):
    '''Function decorator, use this to mark a function as the parser for
    REST as returned by Remember The Milk.
    '''

    def decorate_parser(method):
        rest_parsers[format] = method
        return method

    return decorate_parser


class RtmAPI:
    """Encapsulates Remember The Milk functionality.
    
    Example usage::
      
      rtm = rtmapi.RtmAPI(api_key)

      tasks = rtm.tasks.getList(filter='dueWithin:"1 week of today"')
    """
    
    rtm_host = "api.rememberthemilk.com"
    rtm_rest_form = "/services/rest/"
    rtm_auth_form = "/services/auth/"

    def __init__(self, api_key, secret=None, username=None,
            token=None, format='etree', store_token=True, cache=False):
        """Construct a new RtmAPI instance for a given API key
        and secret.
        
        api_key
            The API key as obtained from Remember The Milk
        
        secret
            The secret belonging to the API key
        
        username
            Used to identify the appropriate authentication token for a
            certain user.
        
        token
            If you already have an authentication token, you can give
            it here. It won't be stored on disk by the RtmAPI instance

        format
            The response format. Use either "xmlnode" or "etree" to get
            a parsed response, or use any response format supported by
            RTM to get an unparsed response from method calls. It's also
            possible to pass the ``format`` parameter on individual
            calls.

        store_token
            Disables the on-disk token cache if set to False (default is
            True). Use this to ensure that tokens aren't read nor
            written to disk, for example in web applications that store
            tokens in cookies.

        cache
            Enables in-memory caching of RtmAPI calls - set to ``True`` to
            use. If you don't want to use the default settings, you can
            instantiate a cache yourself too:

            >>> r = RtmAPI(api_key='123')
            >>> r.cache = SimpleCache(timeout=5, max_entries=100)
       
        """
        
        self.api_key = api_key
        self.secret = secret
        self.default_format = format
        
        self.__handler_cache = {}

        if token:
            # Use a memory-only token cache
            self.token_cache = SimpleTokenCache()
            self.token_cache.token = token
        elif not store_token:
            # Use an empty memory-only token cache
            self.token_cache = SimpleTokenCache()
        else:
            # Use a real token cache
            self.token_cache = TokenCache(api_key, username)

        if cache:
            self.cache = SimpleCache()
        else:
            self.cache = None

    def __repr__(self):
        '''Returns a string representation of this object.'''


        return '[RtmAPI for key "%s"]' % self.api_key
    __str__ = __repr__

    def trait_names(self):
        '''Returns a list of method names as supported by the
        Remember The Milk API. Used for tab completion in IPython.
        '''

        rsp = self.reflection.getMethods(format='etree')

        return [m.text[4:] for m in rsp.getiterator('method')]

    @rest_parser('xmlnode')
    def parse_xmlnode(self, rest_xml):
        '''Parses a REST XML response from Remember The Milk into an
        XMLNode object.'''

        rsp = XMLNode.parse(rest_xml, store_xml=True)
        if rsp['stat'] == 'ok':
            return rsp
        
        err = rsp.err[0]
        raise RtmError(u'Error: %(code)s: %(msg)s' % err)

    @rest_parser('etree')
    def parse_etree(self, rest_xml):
        '''Parses a REST XML response from Remember The Milk into an
        ElementTree object.'''

        # Only import it here, to maintain Python 2.4 compatibility
        import xml.etree.ElementTree

        rsp = xml.etree.ElementTree.fromstring(rest_xml)
        if rsp.attrib['stat'] == 'ok':
            return rsp
        
        err = rsp.find('err')
        raise RtmError(u'Error: %s: %s' % (
            err.attrib['code'], err.attrib['msg']))


    def sign(self, dictionary):
        """Calculate the Remember The Milk signature for a set of params.
        
        data
            a hash of all the params and values to be hashed, e.g.
            ``{"api_key":"AAAA", "auth_token":"TTTT", "key":
            u"value".encode('utf-8')}``

        """

        data = [self.secret]
        for key in sorted(dictionary.keys()):
            data.append(key)
            datum = dictionary[key]
            if isinstance(datum, unicode):
                raise IllegalArgumentException("No Unicode allowed, "
                        "argument %s (%r) should have been UTF-8 by now"
                        % (key, datum))
            data.append(datum)
        
        md5_hash = md5.new()
        md5_hash.update(''.join(data))
        return md5_hash.hexdigest()

    def encode_and_sign(self, dictionary):
        '''URL encodes the data in the dictionary, and signs it using the
        given secret, if a secret was given.
        '''
        
        dictionary = make_utf8(dictionary)
        if self.secret:
            dictionary['api_sig'] = self.sign(dictionary)
        return urllib.urlencode(dictionary)
        
    def __getattr__(self, attrib):
        """Handle all the regular Remember The Milk API calls.
        
        Example::

            rtm.auth.getFrob(api_key="AAAAAA")
            xmlnode = rtm.tasks.getList(list_id='1234')
            xmlnode = rtm.tasks.getList(list_id='1234', format='xmlnode')
            json = rtm.tasks.getList(list_id='1234', format='json')
            etree = rtm.tasks.getList(list_id='1234', format='etree')
        """

        # Refuse to act as a proxy for unimplemented special methods
        if attrib.startswith('_'):
            raise AttributeError("No such attribute '%s'" % attrib)

        # Return a CallSink instance. When an attribute (method) on this
        # CallSink instance is called, the call will be handled by
        # RtmAPI.call_handler
        # The CallSink is initialized with "rtm" as the base namespace.
        return CallSink(self.call_handler, '.'.join(('rtm', attrib)))

    def call_handler(self, **args):
        """Handle the real call made on a CallSink instance.

        args must contain a keyword argument named 'method', with the
        full Remember The Milk API namespace and method name. For
        Example: 
          method='rtm.tasks.getList'
        """

        # Get the method name and see if it's cached
        method = args['method']
        if method in self.__handler_cache:
            return self.__handler_cache[method](**args)
        
        url = "http://" + RtmAPI.rtm_host + RtmAPI.rtm_rest_form

        def handler(**args):
            '''Dynamically created handler for a RTM API call'''

            explicit_format = 'format' in args

            if self.token_cache.token and not self.secret:
                raise ValueError("Auth tokens cannot be used without "
                                 "API secret")

            # Set some defaults
            defaults = {'method': method,
                        'auth_token': self.token_cache.token,
                        'api_key': self.api_key,
                        'format': self.default_format}

            args = self.__supply_defaults(args, defaults)

            return self.__wrap_in_parser(self.__rtm_call,
                    parse_format=args['format'], **args)

        handler.method = method
        self.__handler_cache[method] = handler
        return handler(**args)

    def __supply_defaults(self, args, defaults):
        '''Returns a new dictionary containing ``args``, augmented with defaults
        from ``defaults``.

        Defaults can be overridden, or completely removed by setting the
        appropriate value in ``args`` to ``None``.

        >>> r = RtmAPI('123')
        >>> r._RtmAPI__supply_defaults(
        ...  {'foo': 'bar', 'baz': None, 'token': None},
        ...  {'baz': 'foobar', 'room': 'door'})
        {'foo': 'bar', 'room': 'door'}
        '''

        result = args.copy()
        for key, default_value in defaults.iteritems():
            # Set the default if the parameter wasn't passed
            if key not in args:
                result[key] = default_value

        for key, value in result.copy().iteritems():
            # You are able to remove a default by assigning None, and we can't
            # pass None to Flickr anyway.
            if result[key] is None:
                del result[key]
        
        return result

    def __rtm_call(self, **kwargs):
        '''Performs a Remember The Milk API call with the given arguments.
        The method name itself should be passed as the 'method' parameter.
        
        Returns the unparsed data from Remember The Milk::

            data = self.__rtm_call(method='rtm.test.echo',
                spam='eggs', format='rest')
        '''

        LOG.debug("Calling %s" % kwargs)

        post_data = self.encode_and_sign(kwargs)

        # Return value from cache if available
        if self.cache and post_data in self.cache:
            return self.cache.get(post_data)

        url = "http://" + RtmAPI.rtm_host + RtmAPI.rtm_rest_form
        rtmsocket = urllib.urlopen(url, post_data)
        reply = rtmsocket.read()
        rtmsocket.close()

        # Store in cache, if we have one
        if self.cache is not None:
            self.cache.set(post_data, reply)

        return reply

    def __wrap_in_parser(self, wrapped_method, parse_format, *args, **kwargs):
        '''Wraps a method call in a parser.

        The parser will be looked up by the ``parse_format`` specifier. If there
        is a parser and ``kwargs['format']`` is set, it's set to ``rest``, and
        the response of the method is parsed before it's returned.
        '''

        # Find the parser, and set the format to rest if we're supposed to
        # parse it.
        if parse_format in rest_parsers and 'format' in kwargs:
            kwargs['format'] = 'rest'

        LOG.debug('Wrapping call %s(self, %s, %s)' % (wrapped_method, args,
            kwargs))
        data = wrapped_method(*args, **kwargs)

        # Just return if we have no parser
        if parse_format not in rest_parsers:
            return data

        # Return the parsed data
        parser = rest_parsers[parse_format]
        return parser(self, data)

    def auth_url(self, perms, frob):
        """Return the authorization URL to get a token.

        This is the URL the app will launch a browser toward if it
        needs a new token.
            
        perms
            "read", "write", or "delete"
        frob
            picked up from an earlier call to RtmAPI.auth.getFrob()

        """

        encoded = self.encode_and_sign({
                    "api_key": self.api_key,
                    "frob": frob,
                    "perms": perms})

        return "http://%s%s?%s" % (RtmAPI.rtm_host, \
            RtmAPI.rtm_auth_form, encoded)

    def web_login_url(self, perms):
        '''Returns the web login URL to forward web users to.

        perms
            "read", "write", or "delete"
        '''
        
        encoded = self.encode_and_sign({
                    "api_key": self.api_key,
                    "perms": perms})

        return "http://%s%s?%s" % (RtmAPI.rtm_host, \
            RtmAPI.rtm_auth_form, encoded)
        

    def validate_frob(self, frob, perms):
        '''Lets the user validate the frob by launching a browser to
        the Remember The Milk website.
        '''
        
        auth_url = self.auth_url(perms, frob)
        webbrowser.open(auth_url, True, True)
        
    def get_token_part_one(self, perms="read"):
        """Get a token either from the cache, or make a new one from
        the frob.
        
        This first attempts to find a token in the user's token cache
        on disk. If that token is present and valid, it is returned by
        the method.
        
        If that fails (or if the token is no longer valid based on
        rtm.auth.checkToken) a new frob is acquired.  The frob is
        validated by having the user log into Remember The Milk (with
        a browser).
        
        To get a proper token, follow these steps:
            - Store the result value of this method call
            - Give the user a way to signal the program that he/she
              has authorized it, for example show a button that can be
              pressed.
            - Wait for the user to signal the program that the
              authorization was performed, but only if there was no
              cached token.
            - Call rtmapi.get_token_part_two(...) and pass it the
              result value you stored.
        
        The newly minted token is then cached locally for the next
        run.
        
        perms
            "read", "write", or "delete"           
        
        An example::
        
            (token, frob) = rtm.get_token_part_one(perms='write')
            if not token: raw_input("Press ENTER after you authorized this program")
            rtm.get_token_part_two((token, frob))
        """
        
        # see if we have a saved token
        token = self.token_cache.token
        frob = None

        # see if it's valid
        if token:
            LOG.debug("Trying cached token '%s'" % token)
            try:
                rsp = self.auth.checkToken(auth_token=token, format='xmlnode')

                # see if we have enough permissions
                tokenPerms = rsp.auth[0].perms[0].text
                if tokenPerms == "read" and perms != "read": token = None
                elif tokenPerms == "write" and perms == "delete": token = None
            except RtmError:
                LOG.debug("Cached token invalid")
                self.token_cache.forget()
                token = None

        # get a new token if we need one
        if not token:
            # get the frob
            LOG.debug("Getting frob for new token")
            rsp = self.auth.getFrob(auth_token=None, format='xmlnode')

            frob = rsp.frob[0].text

            # validate online
            self.validate_frob(frob, perms)

        return (token, frob)
        
    def get_token_part_two(self, (token, frob)):
        """Part two of getting a token, see ``get_token_part_one(...)`` for details."""

        # If a valid token was obtained in the past, we're done
        if token:
            LOG.debug("get_token_part_two: no need, token already there")
            self.token_cache.token = token
            return token
        
        LOG.debug("get_token_part_two: getting a new token for frob '%s'" % frob)

        return self.get_token(frob)
    
    def get_token(self, frob):
        '''Gets the token given a certain frob. Used by ``get_token_part_two`` and
        by the web authentication method.
        '''
        
        # get a token
        rsp = self.auth.getToken(frob=frob, auth_token=None)

        token = rsp.find('*/token').text
        LOG.debug("get_token: new token '%s'" % token)
        
        # store the auth info for next time
        self.token_cache.token = token

        return token

def set_log_level(level):
    '''Sets the log level of the logger used by the RtmAPI module.
    
    >>> import rtmapi
    >>> import logging
    >>> rtmapi.set_log_level(logging.INFO)
    '''
    
    import rtmapi.tokencache
    import rtmapi.callsink

    LOG.setLevel(level)
    rtmapi.tokencache.LOG.setLevel(level)
    rtmapi.callsink.LOG.setLevel(level)

if __name__ == "__main__":
    import doctest
    doctest.testmod()

